[PM-24195] fix: Pass userId to refreshToken and setTokens#2374
[PM-24195] fix: Pass userId to refreshToken and setTokens#2374LRNcardozoWDF wants to merge 24 commits intomainfrom
Conversation
|
Great job! No new security vulnerabilities introduced in this pull request |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #2374 +/- ##
==========================================
- Coverage 86.92% 85.81% -1.12%
==========================================
Files 1852 2083 +231
Lines 163591 178622 +15031
==========================================
+ Hits 142203 153285 +11082
- Misses 21388 25337 +3949 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
dc4164c to
0c069d7
Compare
|
Claude finished @LRNcardozoWDF's task in 3m 14s —— View job Code Review — PR #2374
Overall Assessment: APPROVE This PR fixes a race condition in token refresh by capturing the active userId upfront and threading it explicitly through The implementation addresses all previous review feedback (both Claude and fedemkr). Test coverage is comprehensive, including race condition simulation via Code Review DetailsNo findings to report. All changed code follows established patterns, security requirements are met, and error handling is correct. |
| await coordinator.showErrorAlert(error: error) | ||
| services.errorReporter.log(error: error) | ||
| if case let ServerError.error(errorResponse: errorResponse) = error, | ||
| errorResponse.message.contains("Cipher was not encrypted for the current user") { | ||
| services.errorReporter.log(error: BitwardenError.generalError( | ||
| type: "Save item failed", | ||
| message: errorResponse.message, | ||
| error: error, | ||
| )) | ||
| } |
There was a problem hiding this comment.
The original code called services.errorReporter.log(error: error) for all errors from save operations. The new code only logs the specific ServerError matching "Cipher was not encrypted for the current user".
While ServerError (as a NonLoggableError) and URLError were already filtered out by CrashlyticsErrorReporter, other non-network errors (encryption failures, SDK errors, state service errors) were reaching Crashlytics before and will now be silently dropped.
The targeted BitwardenError.generalError logging should be added alongside the existing generic logging, not replace it:
| await coordinator.showErrorAlert(error: error) | |
| services.errorReporter.log(error: error) | |
| if case let ServerError.error(errorResponse: errorResponse) = error, | |
| errorResponse.message.contains("Cipher was not encrypted for the current user") { | |
| services.errorReporter.log(error: BitwardenError.generalError( | |
| type: "Save item failed", | |
| message: errorResponse.message, | |
| error: error, | |
| )) | |
| } | |
| await coordinator.showErrorAlert(error: error) | |
| if case let ServerError.error(errorResponse: errorResponse) = error, | |
| errorResponse.message.contains("Cipher was not encrypted for the current user") { | |
| services.errorReporter.log(error: BitwardenError.generalError( | |
| type: "Save item failed", | |
| message: errorResponse.message, | |
| error: error, | |
| )) | |
| } | |
| services.errorReporter.log(error: error) |
There was a problem hiding this comment.
@claude this is an old version, I already updated this. Review the last changes.
There was a problem hiding this comment.
Claude finished @LRNcardozoWDF's task in 1m 32s —— View job
Re-Review of Latest Changes
- Gather context on latest changes
- Review updated code
- Post review findings
Commits reviewed since last review: 87c4115 ([PM-24195] Fix tests), c758640 ([PM-24195] Fix pr comment)
Previous Findings Status
| Finding | Severity | Status |
|---|---|---|
| Generic error logging removed in AddEditItemProcessor | IMPORTANT | Addressed — else branch restores generic logging for non-targeted errors |
Diagnostic code shares same do block as refresh logic |
SUGGESTED | Addressed — Post-refresh diagnostic wrapped in its own do/catch |
Changes Since Last Review
-
AddEditItemProcessor.swift — Added
elsebranch at line 708 that callsservices.errorReporter.log(error: error)for errors not matching the targeted "Cipher was not encrypted for the current user" message. This restores generic error logging for all other errors. -
AccountTokenProvider.swift — The race condition diagnostic (
getActiveAccountIdcomparison) is now isolated in its owndo/catchblock (lines 111–122), preventing diagnostic failures from affecting the success path. Error type renamed fromTokenRefreshRaceConditionErrortoAccountTokenProviderError. -
AccountTokenProviderTests.swift — Added
@MainActor, fixed asynctearDown, setstateService.activeAccountfixture insetUp, and addedtest_refreshToken_logsError_whenGetUserIdAfterThrowsto verify the innerdo/catchisolation works correctly (token is returned even when the diagnostic check throws). -
AddEditItemProcessorTests.swift — Updated
test_perform_savePressed_serverError_otherMessage_doesNotLogto expect 1 logged error (matching restored generic logging).
Verdict
Both previous findings have been properly addressed. No new issues found in the incremental changes. The code is clean, well-tested, and ready to merge.
Approved ✅
| let userIdAfter = try await stateService.getActiveAccountId() | ||
|
|
||
| if userIdBefore != userIdAfter { | ||
| let error = TokenRefreshRaceConditionError( | ||
| userIdBefore: userIdBefore, | ||
| userIdAfter: userIdAfter, | ||
| ) | ||
| errorReporter.log(error: error) | ||
| } |
There was a problem hiding this comment.
🎨 SUGGESTED - Diagnostic code shares the same do block as the main refresh logic
If getActiveAccountId() on line 111 throws (e.g., the active account was removed during the refresh -- precisely the race condition being investigated), the error propagates to the catch block, which calls onRefreshTokenError and re-throws. This would make a successful token refresh appear as a failure to callers, since setTokens() on line 105 already completed.
Consider wrapping the post-refresh diagnostic section in its own do/catch to keep it isolated from the success path:
// After setTokens succeeds:
do {
let userIdAfter = try await stateService.getActiveAccountId()
if userIdBefore != userIdAfter {
let error = TokenRefreshRaceConditionError(
userIdBefore: userIdBefore,
userIdAfter: userIdAfter,
)
errorReporter.log(error: error)
}
} catch {
errorReporter.log(error: error)
}…32276-verify-user-id-before-refresh-token
…user-id-before-refresh-token
| if case let ServerError.error(errorResponse: errorResponse) = error, | ||
| errorResponse.message.contains("Cipher was not encrypted for the current user") { | ||
| services.errorReporter.log(error: BitwardenError.generalError( | ||
| type: "Save item failed", | ||
| message: errorResponse.message, | ||
| error: error, | ||
| )) | ||
| } else { | ||
| services.errorReporter.log(error: error) | ||
| } |
There was a problem hiding this comment.
🤔 Is this the only endpoint that can throw this error? If not, can we extract to some other place more generic?
🎨 Instead of using if case you can use catch with the specific error and avoid the if else:
catch let ServerError.error(errorResponse):There was a problem hiding this comment.
I didn't found other endpoints, I'll look into it again.
| } catch { | ||
| errorReporter.log(error: error) | ||
| } | ||
|
|
There was a problem hiding this comment.
🤔 If this would happen then I think we shouldn't be setting the tokens in the wrong account. So you could just add the check before setTokens and add the userId: String? as parameter to setTokens so you can pass the user id you have in there instead of getting it again inside the function:
| let userIdAfter = try await stateService.getActiveAccountId() | |
| guard userIdBefore == userIdAfter else { | |
| let error = AccountTokenProviderError( | |
| userIdBefore: userIdBefore, | |
| userIdAfter: userIdAfter, | |
| ) | |
| errorReporter.log(error: error) | |
| return | |
| } | |
| try await tokenService.setTokens( | |
| accessToken: response.accessToken, | |
| refreshToken: response.refreshToken, | |
| expirationDate: expirationDate, | |
| userId: userIdBefore // <-- maybe rename to expectedUserId instead of userIdBefore | |
| ) | |
🤔 Moreover, there could potentially be a race condition between these two as well:
let userIdBefore = try await stateService.getActiveAccountId()
let refreshToken = try await tokenService.getRefreshToken()So perhaps you could add userId: String? to getRefreshToken as well thus you can pass it, so the userId used throughout this refreshToken is as stable as possible.
| // TODO: PM-33074 Remove logs after confirmation that the error doesn't happen anymore. | ||
| let userIdBefore = try await stateService.getActiveAccountId() |
There was a problem hiding this comment.
🤔 I think you can remove the comment if you start passing the user ID to the token service functions. Also I think userIdBefore should be renamed to userIdToRefresh or something like that so it's easier to know what it refers to.
| return | ||
| } catch ServerError.error(errorResponse: let errorResponse) | ||
| where errorResponse.message.contains("Cipher was not encrypted for the current user") { | ||
| let serverError = ServerError.error(errorResponse: errorResponse) |
There was a problem hiding this comment.
🎨 No need to recreate the server error, you already have it in the error variable.
f466c8e to
50d0f59
Compare
… cmcg/pm-24195-log-error-response-model # Conflicts: # BitwardenShared/Core/Platform/Services/API/AccountTokenProvider.swift # BitwardenShared/Core/Platform/Services/API/AccountTokenProviderTests.swift # TestHelpers/API/MockHTTPClient.swift
50d0f59 to
4ac7416
Compare
There was a problem hiding this comment.
👍 Looks good to me, I'd wait for @KatherineInCode or @matt-livefront's approval as well in case I've missed anything as this touches the account tokens.
| var getAccessTokenUserId: String? | ||
| var getRefreshTokenUserId: String? |
There was a problem hiding this comment.
⛏️ Does it make sense to keep these next to getRefreshTokenResult and getRefreshTokenResult. That's the pattern we've used elsewhere and keeps them alphabetized.
| func getActiveAccountId() async throws -> String { | ||
| try await stateService.getActiveAccountId() |
There was a problem hiding this comment.
🤔 Instead of exposing this via TokenService, would it be better to pass ActiveAccountStateProvider (implemented by StateService) to AccountTokenProvider?
| // Track which userId was used in explicit userId methods | ||
| var getAccessTokenCalledWithUserId: String? | ||
| var getRefreshTokenCalledWithUserId: String? | ||
| var setTokensCalledWithUserId: String? | ||
| var activeAccountId: String = "1" | ||
| var accessTokenByUserId: [String: String] = [:] | ||
| var refreshTokenByUserId: [String: String] = [:] |
There was a problem hiding this comment.
⛏️ Can you alphabetize these with the other properties?
| func getAccessToken(userId: String) async throws -> String { | ||
| try await keychainRepository.getAccessToken(userId: userId) | ||
| } | ||
|
|
||
| func getActiveAccountId() async throws -> String { |
There was a problem hiding this comment.
⛏️ Can you alphabetize these methods?
| httpService: HTTPService, | ||
| timeProvider: TimeProvider = CurrentTime(), | ||
| tokenService: TokenService, | ||
| errorReporter: ErrorReporter, |
There was a problem hiding this comment.
🤔 Should these be alphabetized?

🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-24195
📔 Objective
There's a possible race condition when refreshing the token with that in mind I'm adding more logs and passing the userId of the active account.